[id].astro 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. ---
  2. import Layout from "@layouts/layout.astro";
  3. import Nav from "@components/Nav.astro";
  4. import KlaskBoard from "@components/KlaskBoard.astro";
  5. import VirtualGame from "@components/VirtualGame.astro";
  6. import { init, getGame, getUser } from "@utils/db";
  7. import { getUserFromRequest } from "@utils/auth";
  8. await init();
  9. const { id } = Astro.params;
  10. if (!id) return Astro.redirect("/");
  11. const game = await getGame(id);
  12. if (!game) {
  13. return new Response("Game not found", { status: 404 });
  14. }
  15. const currentUserName = getUserFromRequest(Astro.request);
  16. const currentUser = currentUserName ? await getUser(currentUserName) : null;
  17. const isPlayer1 = currentUserName === game.player1;
  18. const isPlayer2 = currentUserName === game.player2;
  19. const isParticipant = isPlayer1 || isPlayer2;
  20. const canJoin = !isParticipant && game.status === "waiting" && currentUserName && currentUserName !== game.player1;
  21. const origin = new URL(Astro.request.url).origin;
  22. const gameUrl = `${origin}/game/${id}`;
  23. const isVirtual = game.mode === "virtual";
  24. ---
  25. <Layout
  26. title={`${game.player1} vs ${game.player2 ?? "?"}`}
  27. currentUser={currentUserName}
  28. currentRating={currentUser?.rating ?? null}
  29. >
  30. <Nav currentUser={currentUserName} currentRating={currentUser?.rating ?? null} />
  31. <main class="container game-page">
  32. <div class="mode-badge-row">
  33. <span class:list={["mode-badge", isVirtual ? "mode-virtual" : "mode-inperson"]}>
  34. {isVirtual ? "🌐 Virtual" : "🏓 In Person"}
  35. </span>
  36. </div>
  37. {/* --- WAITING STATE (both modes) --- */}
  38. {game.status === "waiting" && (
  39. <div class="waiting-layout">
  40. {!isVirtual && (
  41. <div class="board-side">
  42. <KlaskBoard size="lg" />
  43. </div>
  44. )}
  45. <div class="score-side">
  46. <div class="waiting-panel card">
  47. <p class="section-label">Waiting for opponent</p>
  48. <h2 class="waiting-title">Share this game</h2>
  49. <div class="link-row" style="margin-bottom:16px">
  50. <input type="text" id="share-link" value={gameUrl} readonly />
  51. <button id="copy-link-btn" class="btn btn-secondary" style="width:auto; padding:10px 14px; flex-shrink:0">Copy</button>
  52. </div>
  53. <div class="qr-wrap">
  54. <img src={`/api/qr/${id}`} alt="QR Code" class="qr-img" />
  55. </div>
  56. {canJoin && (
  57. <button id="join-btn" class="btn" style="width:100%; padding:12px; margin-top:16px">
  58. Join as {currentUserName}
  59. </button>
  60. )}
  61. {!currentUserName && (
  62. <a href={`/login?redirect=/game/${id}`} class="btn" style="width:100%; padding:12px; margin-top:16px; display:block; text-align:center">
  63. Sign in to join
  64. </a>
  65. )}
  66. <p class="muted" style="font-size:12px; margin-top:12px; text-align:center">
  67. Created by <strong>{game.player1}</strong>
  68. </p>
  69. </div>
  70. </div>
  71. </div>
  72. )}
  73. {/* --- VIRTUAL ACTIVE GAME --- */}
  74. {isVirtual && game.status === "active" && game.player2 && (
  75. <VirtualGame
  76. gameId={id}
  77. isHost={isPlayer1}
  78. player1={game.player1}
  79. player2={game.player2}
  80. currentUser={currentUserName ?? ""}
  81. />
  82. )}
  83. {/* --- IN-PERSON ACTIVE GAME --- */}
  84. {!isVirtual && game.status !== "waiting" && game.status !== "complete" && (
  85. <div class="game-layout">
  86. <div class="board-side">
  87. <KlaskBoard size="lg" />
  88. </div>
  89. <div class="score-side">
  90. <div class="scoreboard">
  91. <div class="player-row player-top" id="p2-row">
  92. <div class="player-name-wrap">
  93. <span class="player-label-badge p2-badge">●</span>
  94. <a href={`/profile/${encodeURIComponent(game.player2 ?? "")}`} class="player-link">
  95. {game.player2 ?? "Player 2"}
  96. </a>
  97. {isPlayer2 && <span class="you-tag">you</span>}
  98. </div>
  99. <button class="score-btn score-btn-p2" id="score-p2" data-player="2" disabled={!isParticipant || undefined}>
  100. <span class="score-num" id="score2">{game.score2}</span>
  101. <span class="score-plus">+1</span>
  102. </button>
  103. </div>
  104. <div class="progress-bars">
  105. <div class="progress-bar"><div class="progress-fill p1-fill" id="p1-fill" style={`width: ${(game.score1 / 6) * 100}%`}></div></div>
  106. <div class="progress-divider"><span class="progress-label">First to 6</span></div>
  107. <div class="progress-bar"><div class="progress-fill p2-fill" id="p2-fill" style={`width: ${(game.score2 / 6) * 100}%`}></div></div>
  108. </div>
  109. <div class="player-row player-bottom" id="p1-row">
  110. <div class="player-name-wrap">
  111. <span class="player-label-badge p1-badge">●</span>
  112. <a href={`/profile/${encodeURIComponent(game.player1)}`} class="player-link">
  113. {game.player1}
  114. </a>
  115. {isPlayer1 && <span class="you-tag">you</span>}
  116. </div>
  117. <button class="score-btn score-btn-p1" id="score-p1" data-player="1" disabled={!isParticipant || undefined}>
  118. <span class="score-num" id="score1">{game.score1}</span>
  119. <span class="score-plus">+1</span>
  120. </button>
  121. </div>
  122. {isParticipant && (
  123. <div class="game-actions">
  124. <button id="end-game-btn" class="btn btn-danger" style="width:100%; padding:11px">
  125. End & Submit Result
  126. </button>
  127. <p class="muted" style="font-size:11px; text-align:center; margin-top:6px">
  128. Ends the game and saves ratings
  129. </p>
  130. </div>
  131. )}
  132. {!isParticipant && (
  133. <p class="muted" style="font-size:12px; text-align:center; margin-top:16px">Spectating</p>
  134. )}
  135. </div>
  136. </div>
  137. </div>
  138. )}
  139. {/* --- COMPLETE (both modes) --- */}
  140. {game.status === "complete" && (
  141. <div class="result-center">
  142. <div class="result-panel card">
  143. <p class="section-label">Game Over</p>
  144. <h2 class="result-winner">
  145. {game.winner} <span class="green">wins!</span>
  146. </h2>
  147. <div class="final-score">
  148. <span>{game.player1}</span>
  149. <span class="final-score-num">{game.score1} — {game.score2}</span>
  150. <span>{game.player2}</span>
  151. </div>
  152. <div id="rating-changes" class="rating-changes"></div>
  153. <div style="display:flex; gap:8px; margin-top:20px">
  154. <a href="/play" class="btn" style="flex:1; text-align:center; padding:11px">New Game</a>
  155. <a href="/" class="btn btn-secondary" style="flex:1; text-align:center; padding:11px">Home</a>
  156. </div>
  157. </div>
  158. </div>
  159. )}
  160. </main>
  161. </Layout>
  162. <script define:vars={{ gameId: id, initialStatus: game.status, isParticipant, gameUrl, isVirtual }}>
  163. // Only run polling/button logic for in-person games
  164. if (!isVirtual) {
  165. let pollInterval = null;
  166. async function pollGame() {
  167. const res = await fetch(`/api/game/${gameId}`);
  168. if (!res.ok) return;
  169. const game = await res.json();
  170. const s1 = document.getElementById("score1");
  171. const s2 = document.getElementById("score2");
  172. if (s1) s1.textContent = game.score1;
  173. if (s2) s2.textContent = game.score2;
  174. const p1Fill = document.getElementById("p1-fill");
  175. const p2Fill = document.getElementById("p2-fill");
  176. if (p1Fill) p1Fill.style.width = `${(game.score1 / 6) * 100}%`;
  177. if (p2Fill) p2Fill.style.width = `${(game.score2 / 6) * 100}%`;
  178. if (game.status === "active" && initialStatus === "waiting") location.reload();
  179. if (game.status === "complete" && initialStatus !== "complete") location.reload();
  180. }
  181. document.querySelectorAll(".score-btn").forEach(btn => {
  182. btn.addEventListener("click", async () => {
  183. const player = btn.dataset.player;
  184. btn.disabled = true;
  185. const res = await fetch(`/api/game/${gameId}/score`, {
  186. method: "PATCH",
  187. headers: { "Content-Type": "application/json" },
  188. body: JSON.stringify({ player: parseInt(player) }),
  189. });
  190. btn.disabled = false;
  191. if (res.ok) {
  192. const game = await res.json();
  193. const s1 = document.getElementById("score1");
  194. const s2 = document.getElementById("score2");
  195. if (s1) s1.textContent = game.score1;
  196. if (s2) s2.textContent = game.score2;
  197. const p1Fill = document.getElementById("p1-fill");
  198. const p2Fill = document.getElementById("p2-fill");
  199. if (p1Fill) p1Fill.style.width = `${(game.score1 / 6) * 100}%`;
  200. if (p2Fill) p2Fill.style.width = `${(game.score2 / 6) * 100}%`;
  201. }
  202. });
  203. });
  204. const endBtn = document.getElementById("end-game-btn");
  205. if (endBtn) {
  206. endBtn.addEventListener("click", async () => {
  207. if (!confirm("End the game and save results?")) return;
  208. endBtn.disabled = true;
  209. endBtn.textContent = "Saving...";
  210. const res = await fetch(`/api/game/${gameId}/complete`, { method: "POST" });
  211. if (res.ok) location.reload();
  212. else { endBtn.disabled = false; endBtn.textContent = "End & Submit Result"; }
  213. });
  214. }
  215. if (initialStatus === "waiting" || initialStatus === "active") {
  216. pollInterval = setInterval(pollGame, 3000);
  217. }
  218. }
  219. // Shared: waiting state buttons
  220. const copyLinkBtn = document.getElementById("copy-link-btn");
  221. if (copyLinkBtn) {
  222. copyLinkBtn.addEventListener("click", () => {
  223. navigator.clipboard.writeText(gameUrl);
  224. copyLinkBtn.textContent = "Copied!";
  225. setTimeout(() => { copyLinkBtn.textContent = "Copy"; }, 2000);
  226. });
  227. }
  228. const joinBtn = document.getElementById("join-btn");
  229. if (joinBtn) {
  230. joinBtn.addEventListener("click", async () => {
  231. joinBtn.disabled = true;
  232. const res = await fetch(`/api/game/${gameId}`, { method: "POST" });
  233. if (res.ok) location.reload();
  234. else { joinBtn.disabled = false; alert("Failed to join game."); }
  235. });
  236. }
  237. // For virtual waiting, also poll for opponent join
  238. if (isVirtual && initialStatus === "waiting") {
  239. setInterval(async () => {
  240. const res = await fetch(`/api/game/${gameId}`);
  241. if (!res.ok) return;
  242. const game = await res.json();
  243. if (game.status === "active") location.reload();
  244. }, 2000);
  245. }
  246. </script>
  247. <style>
  248. .game-page { padding: 24px 16px 48px; }
  249. .mode-badge-row { margin-bottom: 16px; }
  250. .mode-badge {
  251. display: inline-block;
  252. font-size: 11px;
  253. letter-spacing: 0.06em;
  254. padding: 4px 10px;
  255. border-radius: 3px;
  256. text-transform: uppercase;
  257. }
  258. .mode-inperson { background: rgba(129,182,76,0.12); color: var(--green); border: 1px solid rgba(129,182,76,0.25); }
  259. .mode-virtual { background: rgba(90,140,220,0.12); color: #7aabee; border: 1px solid rgba(90,140,220,0.25); }
  260. .waiting-layout, .game-layout {
  261. display: flex;
  262. gap: 40px;
  263. align-items: flex-start;
  264. justify-content: center;
  265. }
  266. .board-side { flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; }
  267. .score-side { flex: 0 0 320px; }
  268. .waiting-panel { text-align: center; }
  269. .waiting-title { font-family: 'Bebas Neue', sans-serif; font-size: 1.6rem; letter-spacing: 0.05em; margin-bottom: 16px; }
  270. .link-row { display: flex; gap: 8px; }
  271. .link-row input { font-size: 12px; }
  272. .qr-wrap { display: flex; justify-content: center; margin: 0 auto; }
  273. .qr-img { width: 160px; height: 160px; background: white; padding: 10px; border-radius: 4px; display: block; }
  274. .scoreboard { display: flex; flex-direction: column; gap: 16px; }
  275. .player-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
  276. .player-name-wrap { display: flex; align-items: center; gap: 8px; flex: 1; }
  277. .player-label-badge { font-size: 10px; }
  278. .p1-badge { color: #5c8bd6; }
  279. .p2-badge { color: #d46060; }
  280. .player-link { color: var(--text); text-decoration: none; font-size: 14px; font-weight: 500; }
  281. .player-link:hover { color: var(--green-light); }
  282. .you-tag { font-size: 10px; color: var(--green); background: rgba(129,182,76,0.15); border: 1px solid rgba(129,182,76,0.3); border-radius: 3px; padding: 1px 5px; }
  283. .score-btn { background: var(--card); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-family: 'DM Mono', monospace; cursor: pointer; display: flex; flex-direction: column; align-items: center; padding: 8px 16px; gap: 2px; transition: background 0.1s, transform 0.08s; min-width: 72px; }
  284. .score-btn:hover:not(:disabled) { background: var(--card-hover); }
  285. .score-btn:active:not(:disabled) { transform: scale(0.96); }
  286. .score-btn:disabled { cursor: default; opacity: 0.7; }
  287. .score-btn-p1:hover:not(:disabled) { border-color: #5c8bd6; }
  288. .score-btn-p2:hover:not(:disabled) { border-color: #d46060; }
  289. .score-num { font-family: 'Bebas Neue', sans-serif; font-size: 3.5rem; line-height: 1; letter-spacing: 0.02em; }
  290. .score-plus { font-size: 10px; color: var(--text-muted); letter-spacing: 0.05em; }
  291. .score-btn:disabled .score-plus { display: none; }
  292. .progress-bars { display: flex; flex-direction: column; gap: 6px; }
  293. .progress-bar { height: 6px; background: var(--bg-darker); border-radius: 3px; overflow: hidden; }
  294. .progress-fill { height: 100%; border-radius: 3px; transition: width 0.3s ease; }
  295. .p1-fill { background: #5c8bd6; }
  296. .p2-fill { background: #d46060; }
  297. .progress-divider { text-align: center; }
  298. .progress-label { font-size: 10px; color: var(--text-dim); letter-spacing: 0.08em; text-transform: uppercase; }
  299. .result-center { display: flex; justify-content: center; }
  300. .result-panel { text-align: center; max-width: 400px; width: 100%; }
  301. .result-winner { font-family: 'Bebas Neue', sans-serif; font-size: 2.2rem; letter-spacing: 0.05em; margin-bottom: 16px; }
  302. .final-score { display: flex; align-items: center; justify-content: center; gap: 16px; color: var(--text-muted); font-size: 13px; margin-bottom: 8px; }
  303. .final-score-num { font-family: 'Bebas Neue', sans-serif; font-size: 2rem; color: var(--text); }
  304. .rating-changes { font-size: 13px; color: var(--text-muted); margin-top: 8px; }
  305. .game-actions { margin-top: 8px; }
  306. @media (max-width: 800px) {
  307. .game-layout, .waiting-layout { flex-direction: column; align-items: center; }
  308. .score-side { flex: 0 0 auto; width: 100%; max-width: 480px; }
  309. }
  310. </style>